/*
 * Copyright 2009 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.beepmystuff;

import java.util.concurrent.Callable;

import android.util.Log;

/**
 * Implements a high-level wrapper around the Beep My Stuff HTTP API,
 * documented at http://www.beepmystuff.com/static/apidocs/index.html.
 *
 * This class automatically re-requests session tokens as required, reissuing
 * commands as necessary when session tokens expire.  Users provide an optional
 * callback to allow the session token to be persisted, and set the credentials
 * to use via {@link #setCredentials(String, String)}.
 *
 * Instances of this class are thread-safe.
 */
public class BmsClient {
  private static final String TAG = "BMS.BmsClient";

  /**
   * Provides methods to read and write the session token to persistent storage.
   */
  public interface SessionStorage {
    /** Returns the session token, or null if no token is persisted. */
    String getSessionToken();

    /**
     * Saves the session token.  May provide a null token if the current token
     * has expired.
     */
    void setSessionToken(String sessionToken);
  }

  /** The low-level client. */
  private final BmsLowLevelClient client;

  /** The session storage callback. */
  private final SessionStorage sessionStorage;

  /** The current session token, or null if none exists. */
  private String sessionToken = null;
  /** Whether we've fetched an initial session token from storage. */
  private boolean haveReadSessionToken = false;

  /** The most-recently-used nickname. */
  private String nickname = null;

  /** The most-recently-used password. */
  private String password = null;

  /**
   * Creates a new client using the provided low-level client and optional
   * session storage callback.
   *
   * @param client the BMS Low-level client
   * @param sessionStorage the session storage callback, or null if no
   *     persistent storage is available.
   */
  public BmsClient(BmsLowLevelClient client, SessionStorage sessionStorage) {
    this.client = client;
    this.sessionStorage = sessionStorage != null ? sessionStorage : new NullSessionStorage();
  }

  /**
   * Provides credentials for future login attempts (but does not actually
   * force a login).
   *
   * Only invalidates the current session token (if one is available) if the
   * nickname provided is different to that which was previously provided.
   * If no nickname has previously been provided, assumes that the session token
   * should be reused.
   */
  public synchronized void setCredentials(String nickname, String password) {
    if (nickname.equals(this.nickname) && password.equals(this.password)) {
      return;
    }
    Log.v(TAG, "Received new credentials");
    if (this.nickname != null && !this.nickname.equals(nickname)) {
      invalidateSessionToken();
    }
    this.nickname = nickname;
    this.password = password;
  }

  /**
   * Log a user in to to Beep My Stuff to get a valid session token.
   *
   * Users do not need to call this method, but can do so to force a login using
   * the specified nickname and password.  If successful, the session token will
   * be cached, and stored using the session storage callback, and the
   * credentials will be cached for future reuse.
   *
   * @param nickname the nickname of the user who wants to login
   * @param password the API password for the user
   * @return the result of this call
   *
   * @throws BmsTransportException if the request could not be made, or if the
   *     response was unexpected or unintelligible.
   * @throws BmsApiException if an application-level error occurred.
   */
  public synchronized LoginResult login(String nickname, String password)
      throws BmsTransportException, BmsApiException {
    Log.v(TAG, String.format("RPC: login(%s, %s)", nickname, password));
    LoginResult result = client.login(nickname, password);
    this.nickname = nickname;
    this.password = password;
    sessionToken = result.getSessionToken();
    sessionStorage.setSessionToken(sessionToken);
    Log.d(TAG, "got session token " + sessionToken);
    return result;
  }

  /**
   * Result information for the "log in" call.
   * Holds the session token.
   */
  public static interface LoginResult {
    /** Returns the session token associated with this log in. */
    String getSessionToken();
  }

  /**
   * Who to share an item with.
   * @see #addEan(String, ShareWith)
   */
  enum ShareWith {
    /** Visible only to the user. */
    PRIVATE,
    /** Share with the user's friends. */
    FRIENDS,
    /** Share publicly. */
    PUBLIC;

    /** Returns valueOf(name), or defaultValue if the name is not valid. */
    public static ShareWith tryValueOf(String name, ShareWith defaultValue) {
      try {
        return valueOf(name);
      } catch (IllegalArgumentException e) {
        return defaultValue;
      }
    }
  }

  /**
   * Add an EAN to the user's library.
   *
   * @param ean a valid EAN or UPC
   * @param shareWith who the item should be shared with
   * @return the result of this call
   *
   * @throws BmsTransportException if the request could not be made, or if the
   *     response was unexpected or unintelligible.
   * @throws BmsApiException if an application-level error occurred.
   */
  public synchronized AddEanResult addEan(final String ean, final ShareWith shareWith)
      throws BmsTransportException, BmsApiException {
    Log.v(TAG, String.format("RPC: addEan(%s, %s)", ean, shareWith));
    return makeRpcWithAutoRetryOnSessionExpiration(new ApiCallable<AddEanResult>() {
      @Override
      public AddEanResult call() throws BmsApiException, BmsTransportException {
        return client.addEan(sessionToken, ean, shareWith);
      }
    });
  }

  /**
   * Extended result interface for the "Add an EAN" call.
   * Holds extra information about the item that was added, if successful.
   */
  public static interface AddEanResult {
    /**
     * Returns the BeepMyStuff "add" message.
     */
    String getMessage();

    /**
     * Returns the BeepMyStuff error message for the add, if any. This is
     * set when (e.g.) adding an existing item.
     *
     * If this is set then all other fields will be invalid.
     */
    String getErrorMessage();

    /**
     * Returns the image URL associated with this added item. May be empty if
     * there is no image.
     */
    String getImageUrl();

    /**
     * Returns the title of this added item.
     */
    String getTitle();
  }

  /**
   * Returns the path to the user's avatar icon.
   *
   * @return the path to the icon.
   *
   * @throws BmsTransportException if the request could not be made, or if the
   *     response was unexpected or unintelligible.
   * @throws BmsApiException if an application-level error occurred.
   */
  public synchronized String getAvatarPath() throws BmsTransportException, BmsApiException {
    Log.v(TAG, String.format("RPC: getAvatarPath()"));
    return makeRpcWithAutoRetryOnSessionExpiration(new ApiCallable<String>() {
      @Override
      public String call() throws BmsApiException, BmsTransportException {
        return client.getAvatarPath(sessionToken);
      }
    });
  }

  /** A Callable with a narrowed throws clause. */
  private static interface ApiCallable<T> extends Callable<T> {
    @Override
    public T call() throws BmsApiException, BmsTransportException;
  }

  /**
   * Perform an RPC (represented by a Callable), returning the result.
   *
   * Automatically reissues the request (once) if the first request used a
   * cached session token that has expired.
   *
   * @throws BmsTransportException if the request could not be made, or if the
   *     response was unexpected or unintelligible.
   * @throws BmsApiException if an application-level error occurred.
   */
  private <T> T makeRpcWithAutoRetryOnSessionExpiration(ApiCallable<T> callable)
      throws BmsTransportException, BmsApiException {
    if (!haveReadSessionToken) {
      Log.d(TAG, "Reading stored session token, if any");
      sessionToken = sessionStorage.getSessionToken();
      haveReadSessionToken = true;
    }

    // If we have a session token, make a call using our (possibly-stale)
    // token.  Otherwise, fall through to where we'll request a new token.
    if (sessionToken != null) {
      try {
        return callable.call();
      } catch (BmsApiException e) {
        if ((e.getErrorCode() != BmsApiException.ErrorCode.INVALID_SESSION)) {
          // Some other error.
          throw e;
        }
      }

      // Session token was stale, so invalidate what we have and keep going.
      invalidateSessionToken();
    } else {
      Log.d(TAG, "No session token, requesting one");
    }

    // Request a new session token.
    if (nickname == null || password == null) {
      throw new BmsClientException("Can't login, no nickname/password");
    }
    login(nickname, password);
    if (sessionToken == null) {
      throw new BmsClientException("Login resulted in a null session token?");
    }

    // Make the call using our new shiny session token.  Propagate any errors
    // we see.
    try {
      return callable.call();
    } catch (BmsApiException e) {
      if ((e.getErrorCode() == BmsApiException.ErrorCode.INVALID_SESSION)) {
        // Well, that's odd.
        invalidateSessionToken();
      }

      throw e;
    }
  }

  /**
   * Invalidates the cached session token.  Called when an invalid session API
   * error is returned, so that the session token will not be reused.
   */
  private void invalidateSessionToken() {
    Log.v(TAG, "Invalidating session token");
    sessionToken = null;
    sessionStorage.setSessionToken(null);
  }

  /** Returns the cached session token. */
  // VisibleForTesting
  String getCachedSessionToken() {
    return sessionToken;
  }

  /** A null implementation of the SessionStorage interface. */
  private static class NullSessionStorage implements SessionStorage {
    @Override
    public String getSessionToken() {
      return null;
    }

    @Override
    public void setSessionToken(String sessionToken) {
    }
  }
}
